要自己刻 Redis,第一步當然是要讓 Server 聽得懂 Client 在講什麼。
Redis 客戶端與伺服器之間進行通訊時,使用的是一種名為 RESP (Redis Serialization Protocol) 的序列化協定。
今天先把 RESP 的規格看懂,順便幫 Go 專案搭出 parser 需要的基本資料結構。
RESP 是 Redis 自己使用的應用層協定。它的好處是格式夠簡單,大部分內容也還看得懂;對手寫 parser 來說,這點很重要,不然第一週大概就卡死了。
在 RESP2 版本中,通訊的所有內容都由 5 種基本資料類型 組成,每種類型都以一個特定的 prefix byte 開始,並以 \r\n (CRLF) 結尾。
說實在,一開始看 RESP 的文件覺得那些前綴符號像是亂碼,多看幾次才覺得其實滿巧妙的。
| 資料類型 | prefix byte | 說明 | 範例與解析 |
|---|---|---|---|
| Simple Strings | + |
簡單的單行字串,不可包含換行符,通常用於伺服器狀態回應(如 OK) |
+OK\r\n |
| Errors | - |
單行的錯誤訊息,客戶端會將其解析為異常(Exception) | -ERR unknown command\r\n |
| Integers | : |
十進位整數,長度不限但通常在 64 位元整數範圍內 | :1000\r\n |
| Bulk Strings | $ |
二進位安全的字串。先標註長度,再傳送內容。支援換行與二進位資料。 | $6\r\nfoobar\r\n (長度為 6 的字串 foobar) |
| Arrays | * |
複雜的多元素容器。先標註元素個數,後續接著各元素的 RESP 表示。 | *2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n (包含兩個 Bulk String 的陣列) |
此外,RESP 還支援特殊的空值表示:
$-1\r\n(表示 Key 不存在等情況)*-1\r\n
我在 code/resp/resp.go 中,將 RESP 的資料類型抽象為一個 Type(本質上是一個 byte),並使用一個整合的 Value 結構來代表任何一種可能被解析出來的 RESP 資料。
package resp
// Type 代表 RESP 的資料類型
type Type byte
const (
TypeSimpleString Type = '+'
TypeError Type = '-'
TypeInteger Type = ':'
TypeBulkString Type = '$'
TypeArray Type = '*'
)
由於 Go 語言是靜態強型別語言,為了方便在一個變數中儲存不同類型的 RESP 資料,設計了一個包含所有型態屬性的結構,並透過輔助函數(Constructor Functions)來確保型別安全性:
type Value struct {
Type Type // 資料的 RESP 型態
Str string // 用於 SimpleString 和 Error
Num int // 用於 Integer
Bulk []byte // 用於 Bulk String
Array []Value // 用於 Array(遞迴巢狀定義)
}
// 輔助建構函數範例
func NewSimpleString(s string) Value {
return Value{Type: TypeSimpleString, Str: s}
}
func NewBulkString(b []byte) Value {
return Value{Type: TypeBulkString, Bulk: b}
}
這樣的設計滿乾淨的,Array 裡面再放 []Value,剛好可以處理 RESP Array 裡面還包另一個 Array 的情況。
我將解析器的實作邏輯放置於 code/resp/parser.go。
處理 TCP stream時不能每讀一點就直接碰底層連線,所以我先選 Go 標準庫的 bufio.Reader。它可以做緩衝讀取,也方便我後面按字元或按行解析。
type Reader struct {
rd *bufio.Reader
}
func NewReader(rd io.Reader) *Reader {
return &Reader{rd: bufio.NewReader(rd)}
}
readLine因為 RESP 協定中的所有行都以 \r\n (CRLF) 結尾,我們需要一個高效率的 readLine 函數來讀取一整行,並將結尾的 \r\n 剔除:
func (r *Reader) readLine() ([]byte, error) {
// ReadBytes 會一直讀取到出現 '\n' 為止
line, err := r.rd.ReadBytes('\n')
if err != nil {
return nil, err
}
n := len(line)
// 驗證行長度至少為 2 (即只有 \r\n),且倒數第二個字元必須是 \r
if n < 2 || line[n-2] != '\r' {
return nil, errors.New("無效的行結尾格式,必須為 CRLF")
}
// 剔除結尾的 \r\n,只回傳中間的內容
return line[:n-2], nil
}
當開始解析一個完整的 RESP Value 時,首先讀取第一個位元組(Prefix Byte),並依據其值分發給對應的解析子函數:
func (r *Reader) ReadValue() (Value, error) {
typ, err := r.rd.ReadByte()
if err != nil {
return Value{}, err
}
switch Type(typ) {
case TypeSimpleString:
return r.readSimpleString()
case TypeError:
return r.readError()
case TypeInteger:
return r.readIntegerType()
case TypeBulkString:
return r.readBulkString()
case TypeArray:
return r.readArray()
default:
return Value{}, errors.New("未知或不支援的 RESP 類型: " + string(typ))
}
}
我們來寫一小段 Go 程式,確認一下今天建的 Value 結構運作起來是什麼感覺。你可以把這段寫在 main.go 裡跑跑看:
package main
import (
"fmt"
"redis-clone/resp"
)
func main() {
v1 := resp.NewSimpleString("OK")
fmt.Printf("Type: %q, Value: %s\n", v1.Type, v1.Str)
v2 := resp.NewBulkString("hello world")
fmt.Printf("Type: %q, Value: %s\n", v2.Type, v2.Bulk)
}
執行下去:
$ go run main.go
# 預期輸出:
# Type: "+", Value: OK
# Type: "$", Value: hello world
看到這些型別常數乖乖被賦值,我們這層資料結構就算打好地基了!
今天先把 RESP 的資料模型和 parser 骨架搭好。真正的解析邏輯還沒全部寫完,但 Value 和 Reader 這兩個核心已經有雛形了。
明天先拿幾個最簡單的型別開刀,希望不要一開始就被 CRLF 搞到頭痛,明天見!